更多的响应类型

响应String

  • 即调用content()方法:就是上一篇案例中使用到的方法,直接返回模型响应回来的字符串内容。没有元数据、Tokens等信息。
  • call()阻塞式调用和stream()流式调用都有该类型的响应,后续其他响应类型未做特别说明的便为两种调用方式都支持。
  • 这里上篇案例中都用到了,这里不再写案例记录了。

响应ChatResponse/ChatClientResponse

ChatResponse

  • 需调用chatResponse()方法:在ChatResponse中包含多组生成结果、元数据、Tokens等信息。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ChatResponse deepseekChatResponse = deepseekClient.prompt()
    .system(SYSTEM_PROMPT)
    .user(userMessage)
    .call() // 或者.stream()
    .chatResponse();
    // 获取到输出内容
    deepseekChatResponse.getResult().getOutput().getText();
    // 元数据
    deepseekChatResponse.getMetadata();
  • 下面是响应的结果输出的JSON(压缩的)。

    1
    {"result":{"output":{"messageType":"ASSISTANT","metadata":{"finishReason":"STOP","index":0,"role":"ASSISTANT","id":"26a2b93d-ba09-4b60-a1fb-e252290a3fe0","messageType":"ASSISTANT"},"toolCalls":[],"media":[],"prefix":null,"reasoningContent":null,"text":"JVM 采用**分代内存管理**(Generational Memory Management)的设计,**这里内容删减了** 这是对程序运行时行为的经验性优化,符合“二八法则”。"},"metadata":{"finishReason":"STOP","contentFilters":[],"empty":true}},"results":[{"output":{"messageType":"ASSISTANT","metadata":{"finishReason":"STOP","index":0,"role":"ASSISTANT","id":"26a2b93d-ba09-4b60-a1fb-e252290a3fe0","messageType":"ASSISTANT"},"toolCalls":[],"media":[],"prefix":null,"reasoningContent":null,"text":"JVM 采用**分代内存管理**(Generational Memory Management)的设计,**这里内容删减了** 这是对程序运行时行为的经验性优化,符合“二八法则”。"},"metadata":{"finishReason":"STOP","contentFilters":[],"empty":true}}],"metadata":{"id":"26a2b93d-ba09-4b60-a1fb-e252290a3fe0","model":"deepseek-chat","rateLimit":{"requestsLimit":0,"requestsRemaining":0,"requestsReset":0.0,"tokensLimit":0,"tokensRemaining":0,"tokensReset":0.0},"usage":{"promptTokens":23,"completionTokens":583,"totalTokens":606,"nativeUsage":{"completion_tokens":583,"prompt_tokens":23,"total_tokens":606,"prompt_tokens_details":{"cached_tokens":0}}},"promptMetadata":[],"empty":false}}
  • 这里测试过不同AI模型返回的ChatResponse结构是否一样,使用的Deepseek跟OpenAi。结论是大同小异,基本是保持一致的,OpenAi响应的ChatResponse会多几个字段。

ChatClientResponse

  • 与ChatResponse类似,调用chatClientResponse()方法。

  • 相比ChatResponse,ChatClientResponse则不仅包含ChatResponse,还多了ChatClient调用模型的上下文。

    ChatClientResponse

  • 这里应该是因为我没有设置过Advisor,所以上下文是空的,这个后面在学习Advisor再仔细看看。

响应自定义的Java类

  • 该响应方式在call()阻塞式调用支持。也很好理解,stream()流式调用每次响应的数据其实可以看成只有部分数据碎片。做结构化转换肯定是需要待数据全部响应后才可以转换。

  • 通过调用.entity方法,该方法有三个不同参数的重载。

entity(Class type)

  • 用于返回固定的实体类

  • 案例:让AI”列出Java的JDK最近一个版本及其发布时间”

    • 先定义一个实体类,包含version(版本)、releaseDate(发布时间)两个字段
    • 这里record关键字是Java 16之后的新的特性,方便定义一些实体类。
    1
    2
    3
    public record JdkVersionResponse(String version, LocalDate releaseDate) {

    }
    • 调用
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    private final ChatClient deepseekClient;

    private static final String SYSTEM_PROMPT = "你是一个Java专家,请帮忙解答提出的Java相关问题。";

    public void entity() {
    JdkVersionResponse jdkVersionResponse = deepseekClient.prompt()
    .system(SYSTEM_PROMPT)
    .user("列出Java的JDK最近一个版本及其发布时间")
    .call()
    .entity(JdkVersionResponse.class);
    log.info("\nEntity jdkVersionResponse \n-> {}", ConvertorUtils.toJsonString(jdkVersionResponse));
    }
    • 结果

    调用结果

  • 从案例的结果中可以看到,响应的是我们自己定义的Java类,并且返回的输出时封装在对应的字段中的。

entity(ParameterizedTypeReference type)

  • 用于返回一些存在泛型类型,比如List<T>

  • 案例:让AI”列出Java的JDK所有版本以及版本发布时间”

    • 沿用上面的实体类即可,调用过程
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    private final ChatClient deepseekClient;

    private static final String SYSTEM_PROMPT = "你是一个Java专家,请帮忙解答提出的Java相关问题。";

    public void entity() {
    List<JdkVersionResponse> jdkVersionResponses = deepseekClient.prompt()
    .system(SYSTEM_PROMPT)
    .user("列出Java的JDK所有版本以及版本发布时间")
    .call()
    .entity(new ParameterizedTypeReference<List<JdkVersionResponse>>() {});

    log.info("\nEntity jdkVersionResponse list \n-> {}", ConvertorUtils.toJsonString(jdkVersionResponses));
    }
    • 结果,下面是日志输出的Json数组
    1
    2
    Entity jdkVersionResponse list 
    -> [{"version":"JDK 1.0","releaseDate":"1996-01-23"},{"version":"JDK 1.1","releaseDate":"1997-02-19"},{"version":"J2SE 1.2","releaseDate":"1998-12-08"},{"version":"J2SE 1.3","releaseDate":"2000-05-08"},{"version":"J2SE 1.4","releaseDate":"2002-02-06"},{"version":"J2SE 5.0","releaseDate":"2004-09-30"},{"version":"Java SE 6","releaseDate":"2006-12-11"},{"version":"Java SE 7","releaseDate":"2011-07-28"},{"version":"Java SE 8","releaseDate":"2014-03-18"},{"version":"Java SE 9","releaseDate":"2017-09-21"},{"version":"Java SE 10","releaseDate":"2018-03-20"},{"version":"Java SE 11","releaseDate":"2018-09-25"},{"version":"Java SE 12","releaseDate":"2019-03-19"},{"version":"Java SE 13","releaseDate":"2019-09-17"},{"version":"Java SE 14","releaseDate":"2020-03-17"},{"version":"Java SE 15","releaseDate":"2020-09-15"},{"version":"Java SE 16","releaseDate":"2021-03-16"},{"version":"Java SE 17","releaseDate":"2021-09-14"},{"version":"Java SE 18","releaseDate":"2022-03-22"},{"version":"Java SE 19","releaseDate":"2022-09-20"},{"version":"Java SE 20","releaseDate":"2023-03-21"},{"version":"Java SE 21","releaseDate":"2023-09-19"}]
  • 从两个案例中可以看出,其实两个方法是类似的,只是如果需要保留返回类型中的泛型则需要选择第二种。

entity(StructuredOutputConverter converter)

  • 用于通过StructuredOutputConverter转换器自行将String转换为目标类型。

  • 案例:这里让AI”给出Java的JDK使用最广泛的一个版本及其发布时间”

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    Map<String, String> mapResponse = deepseekClient.prompt()
    .system(SYSTEM_PROMPT)
    .user("给出Java的JDK使用最广泛的一个版本及其发布时间")
    .call()
    .entity(new StructuredOutputConverter<Map<String, String>>() {
    @Override
    public String getFormat() {
    return "{\"jdk_version\" : \"jdk版本\", \"jdk_release_date\" : \"jdk发布日期\"}";
    }

    @Override
    public Map<String, String> convert(String source) {
    // 将String转换成Map
    source = source.replaceAll("```json", "");
    source = source.replaceAll("```", "");
    return ConvertorUtils.parseJsonObject(source, Map.class);
    }
    });
  • 从上面代码中可以看到需要自定义一个StructuredOutputConverter,并重写其convert(String source)方法,在里面实现转换过程。

  • 这里不放结果了,放在下面还有“自定义响应结构”里面了。

自定义响应结构

如何自定义响应结构

  • 上面看到了自定义的StructuredOutputConverter,除了重写convert(String source)方法,还重写了一个getFormat()方法。这个方法就是自定义响应结构的关键了,先看看上面案例返回的结果。

    案例结果

  • 从结果上可以看到是按照指定的格式"{\"jdk_version\" : \"jdk版本\", \"jdk_release_date\" : \"jdk发布日期\"}"传入到convert(String source)方法的。

  • 为什么上面还写了两行.replaceAll?最开始测试的时候给返回的是markdown代码块,格式如下面这样。这里应该是后面写了其他的案例,在写blog记录时去请求Deepseek命中了缓存,直接给去掉了markdown代码块只保留了内容。往后面继续看就知道。

    1
    2
    3
    4
    5
    6
    ```json
    {
    "jdk_version": "JDK 8 (1.8)",
    "jdk_release_date": "2014年3月18日"
    }
    ​```

自定义结构出现的问题

  • 上面有说到当时响应一直会出现````json markdown`代码块,虽然非常机智的用replaceAll解决了,但是有点low啊。

  • 然后就好奇entity(Class<T> type)是怎么实现的,那么到这里基本上也能猜测到也是通过StructuredOutputConverter来实现结构化响应的。那总不会也是用replaceAll去解决吧。

  • 然后就发现了一个StructuredOutputConverter的子类org.springframework.ai.converter.BeanOutputConverter,急忙找他是如何实现getFormat()方法的,一看原来是加了提示词。下面是源码

    1
    2
    3
    4
    public String getFormat() {
    String template = "Your response should be in JSON format.\nDo not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.\nDo not include markdown code blocks in your response.\nRemove the ```json markdown from the output.\nHere is the JSON Schema instance your output must adhere to:\n```%s```\n";
    return String.format(template, this.jsonSchema);
    }
  • 在模版中要求了不允许有markdown代码块,输出中也必须删除 ```json markdown 等。

  • 然后就有了另外一个案例,依葫芦画瓢这种事儿还是很擅长的,增加了返回格式要求的提示词(中文版),并且格式是数组格式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    List<String> listResponse = deepseekClient.prompt()
    .system(SYSTEM_PROMPT)
    .user("给出Java的JDK使用最广泛的一个版本及其发布时间")
    .call()
    .entity(new StructuredOutputConverter<List<String>>() {
    @Override
    public String getFormat() {
    String format = "你的响应格式必须按照下列给出格式输出。\n不需要做任何解释\n不要在响应中包含markdown代码块\n从输出中删除```json markdown\n以下是你的输出格式:\n```%s```\n";
    return String.format(format, "[\"jdk版本\", \"jdk发布日期(YYYY_MM_DD格式)}\"]");
    }

    @Override
    public List<String> convert(String source) {
    return ConvertorUtils.parseJsonObject(source, List.class);
    }
    });
    log.info("\nstructuredOutputConverter listResponse \n-> {}", ConvertorUtils.toJsonString(listResponse));

  • 输出的结果。案例的convert(String source)方法中是没做任何处理直接转换的。

    1
    2
      structuredOutputConverter listResponse 
    -> [ "jdk8", "2014_03_18" ]

伟大都源于一个勇敢的尝试

  • 既然Json都可以,那我就是不想使用Json,想自己另外定义一个格式。

  • 就又有了一个案例,以-隔开两个需要的内容,就是讲究一个随便。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    Map<String, String> customizeResponse = deepseekClient.prompt()
    .system(SYSTEM_PROMPT)
    .user("给出Java的JDK使用最广泛的一个版本及其发布时间")
    .call()
    .entity(new StructuredOutputConverter<Map<String, String>>() {
    @Override
    public String getFormat() {
    String format = "你的响应格式必须按照下列给出格式输出。\n不需要做任何解释\n不要在响应中包含markdown代码块\n从输出中删除```json markdown\n以下是你的输出格式:\n```%s```\n";
    return String.format(format, "{jdk版本} - {jdk发布日期(YY/MM/DD格式)}");
    }

    @Override
    public Map<String, String> convert(String source) {
    String[] split = source.split(" - ");
    return new HashMap<>(){{
    put("v", split[0]);
    put("date", split[1]);
    }};
    }
    });
    log.info("\nstructuredOutputConverter customizeResponse \n-> {}", ConvertorUtils.toJsonString(customizeResponse));

  • 输出结果。从结果上看,没有任何问题。

    1
    2
    3
    4
    5
    structuredOutputConverter customizeResponse 
    -> {
    "date" : "14/03/18",
    "v" : "Java 8"
    }

总结

  • ChatClient 在调用模型后,提供了多种响应类型可选择,直接响应本文内容的、响应包含元数据信息的ChatResponse、响应包含上下文的ChatClientResponse。
  • 还有响应实体类的entity方法,固定实体类的、带泛型的、自定义结构化转换器的。
  • 可以通过自定义结构化转换器来自定义响应格式。

临时突发疑问

  • 记录到最后突然有个疑问,既想要响应实体类、有想要返回元数据信息呢?主打一个既要有要。
  • 看了一下代码,发现有个.responseEntity方法,参数与.entity方法一样,有三个重载。返回的ResponseEntity里面包含了ChatResponse、和实体类。
  • SpringAI的文档上好像也没看到这个.responseEntity方法,与.entity方法基本一致的,这里也不做记录案例了。

最后

  • 从使用上看,StructuredOutputConverter似乎更灵活,也应该会更贴近开发使用。
  • 简单看了一下源码,SpringAI封装了Bean\List\Map三个转换器,一般使用应该够了,特殊的还是得自己来封装。
  • 在SpringAI文档上,还有一节专门的结构化输出的内容,后续还会再继续深入学习这一块。
  • 案例没有特殊说明,默认都是基于Deepseek模型的。所有案例的源码,都会提交在GitHub上。包:com.spring.ai.example.advisor.two